// Copyright ©2011-2014 Kris Maglione <maglione.k@gmail.com>
// Copyright ©2012-2014 Mozilla Corporation
//
// This work is licensed for reuse under an MIT license. Details are
// given in the LICENSE file included with this file.
"use strict";

const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components;

let resProto = Cc["@mozilla.org/network/io-service;1"].getService(Ci.nsIIOService)
    .getProtocolHandler("resource").QueryInterface(Ci.nsIResProtocolHandler);

let ios = Cc["@mozilla.org/network/io-service;1"].getService(Ci.nsIIOService);

if (!resProto.hasSubstitution("exttest"))
    resProto.setSubstitution("exttest", ios.newURI(Components.stack.filename.replace(/[^\/]*$/, "") + "../modules/", null, null));

var { Services, _, debug, delay, fixURL, isLocalProtocol, isUIProtocol, locations, log, prefs, reportError, wrapCallback } = Cu.import("resource://exttest/exttest.jsm", {});
var { Globals } = Cu.import("resource://exttest/globals.jsm", {});
var { jQueryCheck } = Cu.import("resource://exttest/jquery-wrapper.jsm", {});
var { XPCOMUtils }  = Cu.import("resource://gre/modules/XPCOMUtils.jsm", {});

var systemPrincipal = Cc["@mozilla.org/systemprincipal;1"].getService(Ci.nsIPrincipal);

const TYPES = [
    "SCRIPT",
    "IMAGE",
    "STYLESHEET",
    "OBJECT",
    "SUBDOCUMENT",
    "XMLHTTPREQUEST",
    "OBJECT_SUBREQUEST",
    "DTD",
    "FONT",
    "MEDIA"
];

if (!("find" in Array.prototype)) {
    // Cheap, but meh.
    // Note to editors: builtin prototypes in component scopes are
    // private and may be freely modified.
    Object.defineProperty(Array.prototype, "find", {
        configurable: true,
        value: function find(pred, self) this.filter(pred, self)[0],
        writable: true
    });
    Array.find = Function.call.bind(Array.prototype.find);
}

var ConsoleListener = {
    QueryInterface: XPCOMUtils.generateQI(Ci.nsIConsoleListener),

    retort: function (message) {
        delay(function () {
            Services.console.logStringMessage(message);
        });
    },

    observe: function (message) {
        if (message instanceof Ci.nsIScriptError
            && message.errorMessage == "this.docShell is null"
            && message.sourceName == "chrome://global/content/bindings/browser.xml")
            this.retort("Hi there, your friendly Extension Test instance here. " +
                        "The above message is due to https://bugzil.la/808641 " +
                        "and cannot be readily worked around by extensions which " +
                        "trigger it. It should be ignored.");
    }
};

function getURIChainFlags(uri) {
    let res = 0;
    while (true) {
        res |= Services.io.getProtocolFlags(uri.scheme);
        if (!Ci.nsIProtocolHandler.URI_SAFE_TO_LOAD_IN_SECURE_CONTEXT && uri.schemeIs("https"))
            res |= Ci.nsIProtocolHandler.URI_DOES_NOT_RETURN_DATA; // Hack.

        if (!(uri instanceof Ci.nsINestedURI))
            return res;
        uri = uri.innerURI;
    }
}

function getDocShell(obj) {
    if (obj instanceof Ci.nsIDOMNode && obj.ownerDocument)
        obj = obj.ownerDocument;
    if (obj instanceof Ci.nsIDOMDocument)
        obj = obj.defaultView;
    if (obj instanceof Ci.nsIInterfaceRequestor)
        return obj.getInterface(Ci.nsIDocShell);
    throw Error("Unknown object type");
}

function isSecureConnection(uri) uri.schemeIs("https");

function ContentPolicy() {
}
ContentPolicy.prototype = {
    contractID:        "@dactyl.googlecode.com/exttest/eval-check/content-policy",
    classID:           Components.ID("{38efde93-509e-4957-904f-ee14da90d585}"),
    classDescription:  "Extension Test content policy",
    QueryInterface:    XPCOMUtils.generateQI([Ci.nsIContentPolicy]),

    TYPES: TYPES.map(function (type) Ci.nsIContentPolicy["TYPE_" + type]),

    shouldLoad: function shouldLoad(contentType, contentLocation, requestOrigin, context, mimeType, extra, principal) {
        // Logic mostly copied from nsMixedContentBlocker.cpp

        if (!requestOrigin) {
            if (!principal && context instanceof Ci.nsIDOMNode)
                principal = context.nodePrincipal;
            if (principal) {
                try {
                    requestOrigin = principal.URI;
                }
                catch (e) {}
            }
        }

        // Bail if the origin of the request is not secure or it's of a
        // type we don't care about.
        if (!(requestOrigin && isSecureConnection(requestOrigin) && ~this.TYPES.indexOf(contentType)))
            return Ci.nsIContentPolicy.ACCEPT;

        if (getURIChainFlags(contentLocation) &
                ( Ci.nsIProtocolHandler.URI_IS_LOCAL_RESOURCE
                | Ci.nsIProtocolHandler.URI_DOES_NOT_RETURN_DATA
                | Ci.nsIProtocolHandler.URI_INHERITS_SECURITY_CONTEXT
                | Ci.nsIProtocolHandler.URI_SAFE_TO_LOAD_IN_SECURE_CONTEXT))
            return Ci.nsIContentPolicy.ACCEPT;

        let docShell = getDocShell(context);
        let sameTypeRoot = docShell.sameTypeRootTreeItem || docShell;
        let rootDoc = sameTypeRoot.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMDocument);

        let rootHasSecureConnection = false;
        try {
            rootHasSecureConnection = isSecureConnection(rootDoc.nodePrincipal.URI);
        }
        catch (e) {}

        // If this is a sub-document and the root is not secure, bail
        // unless there's some ancestor document which is.
        if (contentType == Ci.nsIContentPolicy.TYPE_SUBDOCUMENT && !rootHasSecureConnection) {
            for (var parent = docShell; parent; parent = parent.sameTypeParent) {
                parent.QueryInterface(Ci.nsIWebNavigation);
                if (isSecureConnection(parent.currentURI))
                    break;
             }

             if (!parent)
                 return Ci.nsIContentPolicy.ACCEPT;
        }

        // Insecure with secure top-level and some secure ancestor. Balk.
        let type = TYPES[this.TYPES.indexOf(contentType)].toLowerCase();
        if (context instanceof Ci.nsIDOMNode && ~["img", "script", "object", "embed"].indexOf(context.localName))
            type = "<" + context.localName + ">";

        let id = contentLocation.spec + " (" + requestOrigin.spec + ")";
        locations.insecure[id] = true;
        log(_("insecureMedia", type) + ": " + id);

        return Ci.nsIContentPolicy.ACCEPT;
    },

    shouldProcess: function shouldProcess() {
        return Ci.nsIContentPolicy.ACCEPT;
    }
};

function Script(name, test) {
    this.wrappedJSObject = this;
}
Script.prototype = {
    contractID:        "@dactyl.googlecode.com/exttest/eval-check/script",
    classID:           Components.ID("{27521dbf-8182-4ce7-b0f6-2b7d3a25d4ca}"),
    classDescription:  "Extension Test script security check",
    QueryInterface:    XPCOMUtils.generateQI([Ci.nsIObserver]),

    check: wrapCallback(function check(script, caller, privileged) {
        let id;
        caller = Components.stack.caller.caller.caller;
        let frame = caller || { filename: script.baseURI };

        if (caller && !/^\s*$/.test(script.textContent))
            id = fixURL(caller.filename) + ":" + caller.lineNumber;

        if (script.hasAttribute("src")) {
            try {
                var uri = Services.io.newURI(script.src || script.getAttribute("src"),
                                             null, null);

                if (["chrome", "resource"].indexOf(uri.scheme) == -1) {
                    if (caller)
                        id = fixURL(frame.filename) + ":" + frame.lineNumber + ":" + uri.spec;
                    else
                        id = fixURL(script.ownerDocument.documentURI) + ":" + uri.spec;

                }
            }
            catch (e if e.result == Cr.NS_ERROR_MALFORMED_URI) {}
        }

        if (id) {
            locations.scripts[id] = true;
            log(_("unsafeUse", "<script>") + ": " + id
                                           + (uri ? " src=" + uri.spec.quote() : ""),
                null, frame);
        }
    }),

    observe: wrapCallback(function observe(subject, topic, data) {
        let window = subject;

        switch (topic) {
        case "profile-after-change":
            var uri = Services.io.newURI("chrome://exttest/content/script.css", null, null);
            Services.stylesheet.loadAndRegisterSheet(uri, Services.stylesheet.AGENT_SHEET);

            Services.observer.addObserver(this, "chrome-document-global-created", false);
            Services.observer.addObserver(this, "content-document-global-created", false);
            Services.observer.addObserver(this, "http-on-modify-request", false);

            this.urls = [];

            Cc["@mozilla.org/consoleservice;1"].getService(Ci.nsIConsoleService)
                .registerListener(ConsoleListener);
            break;
        case "chrome-document-global-created":
            Globals.mangle(window);
            jQueryCheck(window);
            /* FALLTHROUGH */
        case "content-document-global-created":
            let docShell = window.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIWebNavigation)
                                 .QueryInterface(Ci.nsIDocShellTreeItem);

            if (docShell.itemType != docShell.typeContent)
                if (!window.controllers.getControllerForCommand(Listener.COMMAND))
                    new Listener(window);

            if (this.urls.length > 40)
                this.urls = this.urls.slice(-30);

            if (window.location.href) {
                let idx = this.urls.indexOf(window.location.href);
                if (~idx)
                    this.urls.splice(idx, 1);

                this.urls.push(window.location.href);
            }
            break;
        case "http-on-modify-request":
            uri = subject.QueryInterface(Ci.nsIChannel).URI;

            if (uri instanceof Ci.nsIURL && uri.query) {
                let related = [];

                if (subject instanceof Ci.nsIHttpChannel && subject.referrer)
                    related.push(subject.referrer.spec);

                try {
                    try {
                        var win = subject.loadGroup.notificationCallbacks.getInterface(Ci.nsIDOMWindow);
                    } catch (e) {}

                    (function next(win) {
                        if (win) {
                            related.push(win.location.href);
                            next(win.frameElement && win.parent);
                        }
                    })(win);
                }
                catch (e) {
                    Cu.reportError(e);
                }

                let decode = function decode(v) {
                    try {
                        return decodeURIComponent(v);
                    }
                    catch (e) {
                        return v;
                    }
                }

                let params = uri.query.split("&").map(function (p) decode(p.split("=")[1]));
                let found = this.urls.find(u => !~related.indexOf(u) && ~params.indexOf(u));

                if (found) {
                    if (!locations.tracking.hasOwnProperty(uri.spec))
                        log(_("trackingURL", found, uri.spec));
                    locations.tracking[uri.spec] = true;
                }
            }

            break;
        }
    }),
};

function Listener(window) {
    this.window = window;

    window.QueryInterface(Ci.nsIInterfaceRequestor)
          .getInterface(Ci.nsIWebNavigation)
          .QueryInterface(Ci.nsIWebProgress)
          .addProgressListener(this, Ci.nsIWebProgress.NOTIFY_ALL);

    window.controllers.appendController(this);
}
Listener.COMMAND = "extension-test-request-listener";
Listener.prototype = {
    QueryInterface:    XPCOMUtils.generateQI([Ci.nsIController, Ci.nsISupportsWeakReference, Ci.nsIWebProgressListener]),

    supportsCommand: function supportsCommand(cmd) cmd == Listener.COMMAND,

    onLocationChange: function onLocationChange(webProgress, request, location) {},
    onProgressChange: function onProgressChange(webProgress, request, curSelfProgress, maxSelfProgress, curTotalProgress, maxTotalProgress) {},
    onSecurityChange: function onSecurityChange(webProgress, request, state) {},

    onStateChange: wrapCallback(function onStateChange(webProgress, request, stateFlags, status) {
        const L = Ci.nsIWebProgressListener;

        try {
            var window = webProgress.DOMWindow;
        }
        catch (e) {}

        if (window == this.window) {
            try {
                request.name, request.loadGroup && request.loadGroup.name;
            } catch (e if e.result == Cr.NS_ERROR_NOT_IMPLEMENTED) {
                return;
            }

            let uri = request.originalURI || Services.io.newURI(window.location, null, null);

            if ((stateFlags & L.STATE_START) && (request.loadGroup == null || request.name == request.loadGroup.name)) {
                // Magical Jetpack nonsense
                if (uri.spec == "data:application/vnd.mozilla.xul+xml;charset=utf-8,<window/>")
                    return;

                if (!(isUIProtocol(uri) ||
                      uri.scheme == "data" ||
                      request.owner instanceof Ci.nsIPrincipal && request.owner.equals(systemPrincipal))) {
                    if (!locations.iframes.hasOwnProperty(uri.spec)) {
                        locations.iframes[uri.spec] = true;

                        if (prefs.console.get("<iframe>", true))
                            log(_("unsafeLoad", uri.spec));
                    }
                }
            }
        }
    }),

    onStatusChange: function onStatusChange(webProgress, request, status, message) {}
};

var NSGetFactory = XPCOMUtils.generateNSGetFactory([
    ContentPolicy,
    Script,
]);
